Micron Document
🎖️GitЯра🎖️

Commit 78274c792318331b11ab64cd28e58e829875b8c2


Parents : d60e84f
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2025-11-28T20:05:07-06:00
Committer : GitHub <noreply@github.com>
Date : 2025-11-29T02:05:07Z

feat(navigation): Implement adaptive list-detail for contacts and nodes (#3850)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>

Changes

19 files changed, 633 insertions(+), 225 deletions(-)


Diff

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0f4d4536ed..9f1f8e51fb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -219,6 +219,8 @@ dependencies {
implementation(projects.feature.firmware)
implementation(libs.androidx.compose.material3.adaptive)
+ implementation(libs.androidx.compose.material3.adaptive.layout)
+ implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.navigationSuite)
implementation(libs.material)
implementation(libs.androidx.compose.material3)

diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
index fad262b75c..60f3e60fd6 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
@@ -23,15 +23,12 @@ import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
-import com.geeksville.mesh.ui.contact.ContactsScreen
+import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import kotlinx.coroutines.flow.Flow
-import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.QuickChatScreen
@Suppress("LongMethod")
@@ -40,18 +37,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
) {
- ContactsScreen(
- onClickNodeChip = {
- navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
- launchSingleTop = true
- restoreState = true
- }
- },
- onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
- onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
- onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
- scrollToTopEvents = scrollToTopEvents,
- )
+ AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
}
composable<ContactsRoutes.Messages>(
deepLinks =
@@ -63,13 +49,11 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
- MessageScreen(
- contactKey = args.contactKey,
- message = args.message,
- navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
- navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
- navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
- onNavigateBack = navController::navigateUp,
+ AdaptiveContactsScreen(
+ navController = navController,
+ scrollToTopEvents = scrollToTopEvents,
+ initialContactKey = args.contactKey,
+ initialMessage = args.message,
)
}
}

diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
index 2adb4e103d..85e0043ef4 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
@@ -38,6 +37,8 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
+import androidx.navigation.toRoute
+import com.geeksville.mesh.ui.node.AdaptiveNodeListScreen
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.ContactsRoutes
@@ -45,7 +46,6 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
-import org.meshtastic.core.strings.R
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.environment
@@ -58,8 +58,6 @@ import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.meshtastic.feature.node.detail.NodeDetailScreen
-import org.meshtastic.feature.node.list.NodeListScreen
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
@@ -69,23 +67,27 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
+import kotlin.reflect.KClass
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
- NodeListScreen(
- navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
+ AdaptiveNodeListScreen(
+ navController = navController,
scrollToTopEvents = scrollToTopEvents,
+ onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
- nodeDetailGraph(navController)
+ nodeDetailGraph(navController, scrollToTopEvents)
}
}
@Suppress("LongMethod")
-fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
+fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
+ // We keep this route for deep linking or direct navigation to details,
+ // but typically users will navigate via the Adaptive screen in NodesRoutes.Nodes
navigation<NodesRoutes.NodeDetailGraph>(startDestination = NodesRoutes.NodeDetail()) {
composable<NodesRoutes.NodeDetail>(
deepLinks =
@@ -95,13 +97,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
),
),
) { backStackEntry ->
- val parentEntry =
- remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- NodeDetailScreen(
- navigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
- onNavigate = { navController.navigate(it) },
- onNavigateUp = { navController.navigateUp() },
- viewModel = hiltViewModel(parentEntry),
+ val args = backStackEntry.toRoute<NodesRoutes.NodeDetail>()
+ // When navigating directly to NodeDetail (e.g. from Map or deep link),
+ // we use the Adaptive screen initialized with the specific node ID.
+ AdaptiveNodeListScreen(
+ navController = navController,
+ scrollToTopEvents = scrollToTopEvents,
+ initialNodeId = args.destNum,
+ onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
)
}
@@ -114,88 +117,98 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
- NodeMapScreen(
- hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry),
- onNavigateUp = navController::navigateUp,
- )
+ val vm = hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry)
+ NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
NodeDetailRoute.entries.forEach { entry ->
- when (entry.route) {
- is NodeDetailRoutes.DeviceMetrics ->
+ when (entry.routeClass) {
+ NodeDetailRoutes.DeviceMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.DeviceMetrics>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.PositionLog ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.PositionLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.EnvironmentMetrics ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.EnvironmentMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.EnvironmentMetrics>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.SignalMetrics ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.SignalMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.SignalMetrics>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.PowerMetrics ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.PowerMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PowerMetrics>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.TracerouteLog ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.TracerouteLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.HostMetricsLog ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
entry,
entry.screenComposable,
- )
- is NodeDetailRoutes.PaxMetrics ->
+ ) {
+ it.destNum
+ }
+ NodeDetailRoutes.PaxMetrics::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.PaxMetrics>(
navController,
entry,
entry.screenComposable,
- )
+ ) {
+ it.destNum
+ }
else -> Unit
}
}
}
}
-fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.route::class) }
+fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.routeClass) }
/**
* Helper to define a composable route for a screen within the node detail graph.
*
- * This function simplifies adding screens by handling common tasks like:
- * - Setting up deep links based on the [NodeDetailRoute] definition.
- * - Retrieving the parent [NavBackStackEntry] for the [NodesRoutes.NodeDetailGraph].
- * - Providing the [MetricsViewModel] scoped to the parent graph.
- *
* @param R The type of the [Route] object, must be serializable.
* @param navController The [NavHostController] for navigation.
* @param routeInfo The [NodeDetailRoute] enum entry that defines the path and metadata for this route.
- * @param screenContent A lambda that defines the composable content for the screen. It receives the shared
- * [MetricsViewModel].
+ * @param screenContent A lambda that defines the composable content for the screen.
+ * @param getDestNum A lambda to extract the destination number from the route arguments.
*/
private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenComposable(
navController: NavHostController,
routeInfo: NodeDetailRoute,
crossinline screenContent: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
+ crossinline getDestNum: (R) -> Int,
) {
composable<R>(
deepLinks =
@@ -207,61 +220,66 @@ private inline fun <reified R : Route> NavGraphBuilder.addNodeDetailScreenCompos
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
+
+ val args = backStackEntry.toRoute<R>()
+ val destNum = getDestNum(args)
+ metricsViewModel.setNodeId(destNum)
+
screenContent(metricsViewModel, navController::navigateUp)
}
}
enum class NodeDetailRoute(
val title: StringResource,
- val route: Route,
+ val routeClass: KClass<out Route>,
val icon: ImageVector?,
val screenComposable: @Composable (metricsViewModel: MetricsViewModel, onNavigateUp: () -> Unit) -> Unit,
) {
DEVICE(
Res.string.device,
- NodeDetailRoutes.DeviceMetrics,
+ NodeDetailRoutes.DeviceMetrics::class,
Icons.Default.Router,
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
),
POSITION_LOG(
Res.string.position_log,
- NodeDetailRoutes.PositionLog,
+ NodeDetailRoutes.PositionLog::class,
Icons.Default.LocationOn,
{ metricsVM, onNavigateUp -> PositionLogScreen(metricsVM, onNavigateUp) },
),
ENVIRONMENT(
Res.string.environment,
- NodeDetailRoutes.EnvironmentMetrics,
+ NodeDetailRoutes.EnvironmentMetrics::class,
Icons.Default.LightMode,
{ metricsVM, onNavigateUp -> EnvironmentMetricsScreen(metricsVM, onNavigateUp) },
),
SIGNAL(
Res.string.signal,
- NodeDetailRoutes.SignalMetrics,
+ NodeDetailRoutes.SignalMetrics::class,
Icons.Default.CellTower,
{ metricsVM, onNavigateUp -> SignalMetricsScreen(metricsVM, onNavigateUp) },
),
TRACEROUTE(
Res.string.traceroute,
- NodeDetailRoutes.TracerouteLog,
+ NodeDetailRoutes.TracerouteLog::class,
Icons.Default.PermScanWifi,
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
),
POWER(
Res.string.power,
- NodeDetailRoutes.PowerMetrics,
+ NodeDetailRoutes.PowerMetrics::class,
Icons.Default.Power,
{ metricsVM, onNavigateUp -> PowerMetricsScreen(metricsVM, onNavigateUp) },
),
HOST(
Res.string.host,
- NodeDetailRoutes.HostMetricsLog,
+ NodeDetailRoutes.HostMetricsLog::class,
Icons.Default.Memory,
{ metricsVM, onNavigateUp -> HostMetricsLogScreen(metricsVM, onNavigateUp) },
),
PAX(
Res.string.pax,
- NodeDetailRoutes.PaxMetrics,
+ NodeDetailRoutes.PaxMetrics::class,
Icons.Default.People,
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),

diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt
new file mode 100644
index 0000000000..fb3dfaa081
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.geeksville.mesh.ui.contact
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.navigation.ChannelsRoutes
+import org.meshtastic.core.navigation.ContactsRoutes
+import org.meshtastic.core.navigation.NodesRoutes
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.conversations
+import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.core.ui.icon.Conversations
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.feature.messaging.MessageScreen
+
+@Suppress("LongMethod")
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun AdaptiveContactsScreen(
+ navController: NavHostController,
+ scrollToTopEvents: Flow<ScrollToTopEvent>,
+ initialContactKey: String? = null,
+ initialMessage: String = "",
+) {
+ val navigator = rememberListDetailPaneScaffoldNavigator<String>()
+ val scope = rememberCoroutineScope()
+
+ BackHandler(navigator.canNavigateBack()) { scope.launch { navigator.navigateBack() } }
+
+ LaunchedEffect(initialContactKey) {
+ if (initialContactKey != null) {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
+ }
+ }
+
+ ListDetailPaneScaffold(
+ directive = navigator.scaffoldDirective,
+ value = navigator.scaffoldValue,
+ listPane = {
+ AnimatedPane {
+ ContactsScreen(
+ onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
+ onClickNodeChip = {
+ navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ onNavigateToMessages = { contactKey ->
+ scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
+ },
+ onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
+ scrollToTopEvents = scrollToTopEvents,
+ activeContactKey = navigator.currentDestination?.contentKey,
+ )
+ }
+ },
+ detailPane = {
+ AnimatedPane {
+ val contactKey = navigator.currentDestination?.contentKey
+
+ if (contactKey != null) {
+ MessageScreen(
+ contactKey = contactKey,
+ message = if (contactKey == initialContactKey) initialMessage else "",
+ navigateToMessages = { newContactKey ->
+ scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, newContactKey) }
+ },
+ navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
+ navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
+ onNavigateBack = {
+ if (navigator.canNavigateBack()) {
+ scope.launch { navigator.navigateBack() }
+ } else {
+ navController.navigateUp()
+ }
+ },
+ )
+ } else {
+ PlaceholderScreen()
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun PlaceholderScreen() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(
+ imageVector = MeshtasticIcons.Conversations,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(Res.string.conversations),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}

diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt
index 881b3d5376..2619cb4732 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.twotone.VolumeOff
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -67,20 +68,39 @@ fun ContactItem(
contact: Contact,
selected: Boolean,
modifier: Modifier = Modifier,
+ isActive: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onNodeChipClick: () -> Unit = {},
channels: AppOnlyProtos.ChannelSet? = null,
) = with(contact) {
+ val isOutlined = !selected && !isActive
+
+ val colors =
+ if (isOutlined) {
+ CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
+ } else {
+ val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
+ CardDefaults.cardColors(containerColor = containerColor)
+ }
+
+ val border =
+ if (isOutlined) {
+ CardDefaults.outlinedCardBorder()
+ } else {
+ null
+ }
+
Card(
modifier =
modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
- .background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics { contentDescription = shortName },
shape = RoundedCornerShape(12.dp),
+ colors = colors,
+ border = border,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)

diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
index 2effcacdb3..84ba733ac2 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
@@ -112,6 +112,7 @@ fun ContactsScreen(
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
+ activeContactKey: String? = null,
) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -252,6 +253,7 @@ fun ContactsScreen(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
+ activeContactKey = activeContactKey,
onClick = onContactClick,
onLongClick = onContactLongClick,
onNodeChipClick = onNodeChipClick,
@@ -473,6 +475,7 @@ private fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
+ activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@@ -493,6 +496,7 @@ private fun ContactListViewPaged(
contacts = contacts,
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
+ activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@@ -508,6 +512,7 @@ private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
+ activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@@ -520,6 +525,7 @@ private fun ContactListContentInternal(
contactListPlaceholdersItems(
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
+ activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@@ -530,6 +536,7 @@ private fun ContactListContentInternal(
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
+ activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
@@ -544,6 +551,7 @@ private fun ContactListContentInternal(
private fun LazyListScope.contactListPlaceholdersItems(
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
+ activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@@ -556,10 +564,12 @@ private fun LazyListScope.contactListPlaceholdersItems(
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
+ val isActive = remember(placeholder.contactKey, activeContactKey) { placeholder.contactKey == activeContactKey }
ContactItem(
contact = placeholder,
selected = selected,
+ isActive = isActive,
onClick = { onClick(placeholder) },
onLongClick = {
onLongClick(placeholder)
@@ -574,6 +584,7 @@ private fun LazyListScope.contactListPlaceholdersItems(
private fun LazyListScope.contactListPagedItems(
contacts: LazyPagingItems<Contact>,
selectedList: List<String>,
+ activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
@@ -590,10 +601,12 @@ private fun LazyListScope.contactListPagedItems(
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
+ val isActive = remember(contact.contactKey, activeContactKey) { contact.contactKey == activeContactKey }
ContactItem(
contact = contact,
selected = selected,
+ isActive = isActive,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(contact)

diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt
new file mode 100644
index 0000000000..1b4779e36a
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package com.geeksville.mesh.ui.node
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.navigation.Route
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.nodes
+import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Nodes
+import org.meshtastic.feature.node.detail.NodeDetailScreen
+import org.meshtastic.feature.node.list.NodeListScreen
+
+@Suppress("LongMethod")
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun AdaptiveNodeListScreen(
+ navController: NavHostController,
+ scrollToTopEvents: Flow<ScrollToTopEvent>,
+ initialNodeId: Int? = null,
+ onNavigateToMessages: (String) -> Unit = {},
+) {
+ val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
+ val scope = rememberCoroutineScope()
+
+ val isDetailActive = navigator.currentDestination?.contentKey != null
+
+ BackHandler(enabled = isDetailActive) {
+ scope.launch {
+ if (navigator.canNavigateBack()) {
+ navigator.navigateBack()
+ } else {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.List, null)
+ }
+ }
+ }
+
+ LaunchedEffect(initialNodeId) {
+ if (initialNodeId != null) {
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialNodeId)
+ }
+ }
+
+ ListDetailPaneScaffold(
+ directive = navigator.scaffoldDirective,
+ value = navigator.scaffoldValue,
+ listPane = {
+ AnimatedPane {
+ NodeListScreen(
+ navigateToNodeDetails = { nodeId ->
+ scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
+ },
+ scrollToTopEvents = scrollToTopEvents,
+ activeNodeId = navigator.currentDestination?.contentKey,
+ )
+ }
+ },
+ detailPane = {
+ AnimatedPane {
+ val nodeId = navigator.currentDestination?.contentKey
+ if (nodeId != null) {
+ NodeDetailScreenWrapper(
+ nodeId = nodeId,
+ navigateToMessages = onNavigateToMessages,
+ onNavigate = { route -> navController.navigate(route) },
+ onNavigateUp = { scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.List, null) } },
+ )
+ } else {
+ PlaceholderScreen()
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun NodeDetailScreenWrapper(
+ nodeId: Int,
+ navigateToMessages: (String) -> Unit,
+ onNavigate: (Route) -> Unit,
+ onNavigateUp: () -> Unit,
+) {
+ NodeDetailScreen(
+ onNavigateUp = onNavigateUp,
+ navigateToMessages = navigateToMessages,
+ onNavigate = onNavigate,
+ overrideNodeId = nodeId,
+ )
+}
+
+@Composable
+private fun PlaceholderScreen() {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Icon(
+ imageVector = MeshtasticIcons.Nodes,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(Res.string.nodes),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}

diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index 459cdf0784..cb592d958c 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -64,23 +64,23 @@ object NodesRoutes {
}
object NodeDetailRoutes {
- @Serializable data object DeviceMetrics : Route
+ @Serializable data class DeviceMetrics(val destNum: Int) : Route
- @Serializable data object NodeMap : Route
+ @Serializable data class NodeMap(val destNum: Int) : Route
- @Serializable data object PositionLog : Route
+ @Serializable data class PositionLog(val destNum: Int) : Route
- @Serializable data object EnvironmentMetrics : Route
+ @Serializable data class EnvironmentMetrics(val destNum: Int) : Route
- @Serializable data object SignalMetrics : Route
+ @Serializable data class SignalMetrics(val destNum: Int) : Route
- @Serializable data object PowerMetrics : Route
+ @Serializable data class PowerMetrics(val destNum: Int) : Route
- @Serializable data object TracerouteLog : Route
+ @Serializable data class TracerouteLog(val destNum: Int) : Route
- @Serializable data object HostMetricsLog : Route
+ @Serializable data class HostMetricsLog(val destNum: Int) : Route
- @Serializable data object PaxMetrics : Route
+ @Serializable data class PaxMetrics(val destNum: Int) : Route
}
object SettingsRoutes {

diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 4be413f9bf..0ea758b4c2 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -18,6 +18,7 @@
package org.meshtastic.feature.messaging
import android.os.RemoteException
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
@@ -54,10 +55,12 @@ import javax.inject.Inject
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MessageViewModel
@Inject
constructor(
+ savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
@@ -92,12 +95,21 @@ constructor(
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
.cachedIn(viewModelScope)
+ init {
+ val contactKey = savedStateHandle.get<String>("contactKey")
+ if (contactKey != null) {
+ contactKeyForPagedMessages.value = contactKey
+ }
+ }
+
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
fun getMessagesFromPaged(contactKey: String): Flow<PagingData<Message>> {
- contactKeyForPagedMessages.value = contactKey
+ if (contactKeyForPagedMessages.value != contactKey) {
+ contactKeyForPagedMessages.value = contactKey
+ }
return pagedMessagesForContactKey
}

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
index 035683531b..43a828840a 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
@@ -61,7 +61,7 @@ fun MetricsSection(
TitledCard(title = stringResource(Res.string.logs), modifier = modifier) {
nonPositionLogs.forEach { type ->
ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
- onAction(NodeDetailAction.Navigate(type.route))
+ onAction(NodeDetailAction.Navigate(type.routeFactory(node.num)))
}
}
}

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 66720af7de..a6f4cbaea8 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -61,6 +61,9 @@ import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
+private const val ACTIVE_ALPHA = 0.5f
+private const val INACTIVE_ALPHA = 0.2f
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@@ -74,6 +77,7 @@ fun NodeItem(
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
connectionState: ConnectionState,
+ isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
@@ -91,7 +95,8 @@ fun NodeItem(
thatNode.colors.second
}
?.let {
- val containerColor = Color(it).copy(alpha = 0.2f)
+ val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
+ val containerColor = Color(it).copy(alpha = alpha)
contentColor = contentColorFor(containerColor)
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 48b1e3a8c8..a3b270753b 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -90,7 +90,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
- onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route))
+ onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num)))
}
}
@@ -99,7 +99,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
- onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route))
+ onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
}
}
}

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index 19bda743c0..3c09f7d08c 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -48,7 +49,14 @@ fun NodeDetailScreen(
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
+ overrideNodeId: Int? = null,
) {
+ LaunchedEffect(overrideNodeId) {
+ if (overrideNodeId != null) {
+ viewModel.setNodeId(overrideNodeId)
+ }
+ }
+
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
index 51620b78e8..1fdd315664 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt
@@ -17,6 +17,8 @@
package org.meshtastic.feature.node.list
+import kotlinx.coroutines.flow.map
+import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
@@ -27,6 +29,13 @@ class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSou
val onlyDirect = uiPreferencesDataSource.onlyDirect
val showIgnored = uiPreferencesDataSource.showIgnored
+ val nodeSortOption =
+ uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
+
+ fun setNodeSort(option: NodeSortOption) {
+ uiPreferencesDataSource.setNodeSort(option.ordinal)
+ }
+
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 0c99acc883..ddd650f057 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -91,6 +91,7 @@ fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
+ activeNodeId: Int? = null,
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
@@ -208,6 +209,8 @@ fun NodeListScreen(
null
}
+ val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
+
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
@@ -218,6 +221,7 @@ fun NodeListScreen(
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
connectionState = connectionState,
+ isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index 497bed25da..b520696525 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.node.list
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -32,7 +33,6 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
-import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@@ -44,10 +44,10 @@ import javax.inject.Inject
class NodeListViewModel
@Inject
constructor(
+ private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
- private val uiPreferencesDataSource: UiPreferencesDataSource,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@@ -63,10 +63,9 @@ constructor(
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
- private val nodeSortOption =
- uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
+ private val nodeSortOption = nodeFilterPreferences.nodeSortOption
- private val _nodeFilterText = MutableStateFlow("")
+ private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
private val includeUnknown = nodeFilterPreferences.includeUnknown
private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure
private val onlyOnline = nodeFilterPreferences.onlyOnline
@@ -134,11 +133,11 @@ constructor(
var nodeFilterText: String
get() = _nodeFilterText.value
set(value) {
- _nodeFilterText.value = value
+ savedStateHandle[KEY_FILTER_TEXT] = value
}
fun setSortOption(sort: NodeSortOption) {
- uiPreferencesDataSource.setNodeSort(sort.ordinal)
+ nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
@@ -150,6 +149,10 @@ constructor(
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
+
+ companion object {
+ private const val KEY_FILTER_TEXT = "filter_text"
+ }
}
data class NodesUiState(

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 381732b392..004637957b 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -24,16 +24,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -73,7 +72,7 @@ private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MetricsViewModel
@Inject
@@ -82,13 +81,16 @@ constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
- radioConfigRepository: RadioConfigRepository,
+ private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
) : ViewModel() {
- private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
+ private var destNum: Int? =
+ runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
+
+ private var jobs: Job? = null
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
@@ -132,126 +134,157 @@ constructor(
val timeFrame: StateFlow<TimeFrame> = _timeFrame
init {
- if (destNum != null) {
- nodeRepository.nodeDBbyNum
- .mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
- .distinctUntilChanged()
- .onEach { (node, ourNode) ->
- // Create a fallback node if not found in database (for hidden clients, etc.)
- val actualNode = node ?: createFallbackNode(destNum)
- val deviceHardware =
- actualNode.user.hwModel.safeNumber().let {
- deviceHardwareRepository.getDeviceHardwareByModel(it)
+ initializeFlows()
+ }
+
+ fun setNodeId(id: Int) {
+ if (destNum != id) {
+ destNum = id
+ initializeFlows()
+ }
+ }
+
+ @Suppress("LongMethod")
+ private fun initializeFlows() {
+ jobs?.cancel()
+ val currentDestNum = destNum
+ jobs =
+ viewModelScope.launch {
+ if (currentDestNum != null) {
+ launch {
+ nodeRepository.nodeDBbyNum
+ .mapLatest { nodes -> nodes[currentDestNum] to nodes.keys.firstOrNull() }
+ .distinctUntilChanged()
+ .collect { (node, ourNode) ->
+ // Create a fallback node if not found in database (for hidden clients, etc.)
+ val actualNode = node ?: createFallbackNode(currentDestNum)
+ val deviceHardware =
+ actualNode.user.hwModel.safeNumber().let {
+ deviceHardwareRepository.getDeviceHardwareByModel(it)
+ }
+ _state.update { state ->
+ state.copy(
+ node = actualNode,
+ isLocal = currentDestNum == ourNode,
+ deviceHardware = deviceHardware.getOrNull(),
+ )
+ }
+ }
+ }
+
+ launch {
+ radioConfigRepository.deviceProfileFlow.collect { profile ->
+ val moduleConfig = profile.moduleConfig
+ _state.update { state ->
+ state.copy(
+ isManaged = profile.config.security.isManaged,
+ isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
+ displayUnits = profile.config.display.units,
+ )
+ }
}
- _state.update { state ->
- state.copy(
- node = actualNode,
- isLocal = destNum == ourNode,
- deviceHardware = deviceHardware.getOrNull(),
- )
}
- }
- .launchIn(viewModelScope)
-
- radioConfigRepository.deviceProfileFlow
- .onEach { profile ->
- val moduleConfig = profile.moduleConfig
- _state.update { state ->
- state.copy(
- isManaged = profile.config.security.isManaged,
- isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
- displayUnits = profile.config.display.units,
- )
+
+ launch {
+ meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
+ _state.update { state ->
+ state.copy(
+ deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
+ powerMetrics = telemetry.filter { it.hasPowerMetrics() },
+ hostMetrics = telemetry.filter { it.hasHostMetrics() },
+ )
+ }
+ _environmentState.update { state ->
+ state.copy(
+ environmentMetrics =
+ telemetry.filter {
+ it.hasEnvironmentMetrics() &&
+ it.environmentMetrics.hasRelativeHumidity() &&
+ it.environmentMetrics.hasTemperature() &&
+ !it.environmentMetrics.temperature.isNaN()
+ },
+ )
+ }
+ }
}
- }
- .launchIn(viewModelScope)
-
- meshLogRepository
- .getTelemetryFrom(destNum)
- .onEach { telemetry ->
- _state.update { state ->
- state.copy(
- deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
- powerMetrics = telemetry.filter { it.hasPowerMetrics() },
- hostMetrics = telemetry.filter { it.hasHostMetrics() },
- )
+
+ launch {
+ meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets ->
+ _state.update { state ->
+ state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
+ }
+ }
+ }
+
+ launch {
+ combine(
+ meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
+ meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE),
+ ) { request, response ->
+ _state.update { state ->
+ state.copy(
+ tracerouteRequests = request.filter { it.hasValidTraceroute() },
+ tracerouteResults = response,
+ )
+ }
+ }
+ .collect {}
}
- _environmentState.update { state ->
- state.copy(
- environmentMetrics =
- telemetry.filter {
- it.hasEnvironmentMetrics() &&
- it.environmentMetrics.hasRelativeHumidity() &&
- it.environmentMetrics.hasTemperature() &&
- !it.environmentMetrics.temperature.isNaN()
- },
- )
+
+ launch {
+ meshLogRepository.getMeshPacketsFrom(
+ currentDestNum,
+ PortNum.POSITION_APP_VALUE,
+ ).collect { packets ->
+ val distinctPositions =
+ packets
+ .mapNotNull { it.toPosition() }
+ .asFlow()
+ .distinctUntilChanged { old, new ->
+ old.time == new.time ||
+ (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
+ }
+ .toList()
+ _state.update { state -> state.copy(positionLogs = distinctPositions) }
+ }
}
- }
- .launchIn(viewModelScope)
- meshLogRepository
- .getMeshPacketsFrom(destNum)
- .onEach { meshPackets ->
- _state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
- }
- .launchIn(viewModelScope)
-
- combine(
- meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
- meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
- ) { request, response ->
- _state.update { state ->
- state.copy(
- tracerouteRequests = request.filter { it.hasValidTraceroute() },
- tracerouteResults = response,
- )
- }
- }
- .launchIn(viewModelScope)
-
- meshLogRepository
- .getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
- .onEach { packets ->
- val distinctPositions =
- packets
- .mapNotNull { it.toPosition() }
- .asFlow()
- .distinctUntilChanged { old, new ->
- old.time == new.time ||
- (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
+ launch {
+ meshLogRepository.getLogsFrom(
+ currentDestNum,
+ Portnums.PortNum.PAXCOUNTER_APP_VALUE,
+ ).collect { logs ->
+ _state.update { state -> state.copy(paxMetrics = logs) }
+ }
+ }
+
+ launch {
+ firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
+ _state.update { state -> state.copy(latestStableFirmware = latestStable) }
+ }
+ }
+
+ launch {
+ firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
+ _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
+ }
+ }
+
+ launch {
+ meshLogRepository
+ .getMyNodeInfo()
+ .map { it?.firmwareEdition }
+ .distinctUntilChanged()
+ .collect { firmwareEdition ->
+ _state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
}
- .toList()
- _state.update { state -> state.copy(positionLogs = distinctPositions) }
+ }
+
+ Timber.d("MetricsViewModel created")
+ } else {
+ Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
- .launchIn(viewModelScope)
-
- meshLogRepository
- .getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
- .onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
- .launchIn(viewModelScope)
-
- firmwareReleaseRepository.stableRelease
- .filterNotNull()
- .onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
- .launchIn(viewModelScope)
-
- firmwareReleaseRepository.alphaRelease
- .filterNotNull()
- .onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
- .launchIn(viewModelScope)
-
- meshLogRepository
- .getMyNodeInfo()
- .map { it?.firmwareEdition }
- .distinctUntilChanged()
- .onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
- .launchIn(viewModelScope)
-
- Timber.d("MetricsViewModel created")
- } else {
- Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
- }
+ }
}
override fun onCleared() {

diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt
index 51a7fff9ae..0076b4405f 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt
@@ -42,14 +42,14 @@ import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.sig_metrics_log
import org.meshtastic.core.strings.traceroute_log
-enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val route: Route) {
- DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics),
- NODE_MAP(Res.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap),
- POSITIONS(Res.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog),
- ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, NodeDetailRoutes.EnvironmentMetrics),
- SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, NodeDetailRoutes.SignalMetrics),
- POWER(Res.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics),
- TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog),
- HOST(Res.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog),
- PAX(Res.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics),
+enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val routeFactory: (Int) -> Route) {
+ DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, { NodeDetailRoutes.DeviceMetrics(it) }),
+ NODE_MAP(Res.string.node_map, Icons.Default.Map, { NodeDetailRoutes.NodeMap(it) }),
+ POSITIONS(Res.string.position_log, Icons.Default.LocationOn, { NodeDetailRoutes.PositionLog(it) }),
+ ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }),
+ SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
+ POWER(Res.string.power_metrics_log, Icons.Default.Power, { NodeDetailRoutes.PowerMetrics(it) }),
+ TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, { NodeDetailRoutes.TracerouteLog(it) }),
+ HOST(Res.string.host_metrics_log, Icons.Default.Memory, { NodeDetailRoutes.HostMetricsLog(it) }),
+ PAX(Res.string.pax_metrics_log, Icons.Default.People, { NodeDetailRoutes.PaxMetrics(it) }),
}

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 10a65ecb47..9c576e2457 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -76,6 +76,8 @@ androidx-compose-material-iconsExtended = { module = "androidx.compose.material:
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
+androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" }
+androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
androidx-compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "androidxTracing" }

Served by rngit 1.5.0 - Generated in 0.21s